diff --git a/swh/web/archive_coverage/templates/archive-coverage.html b/swh/web/archive_coverage/templates/archive-coverage.html index 3d236351..1668543f 100644 --- a/swh/web/archive_coverage/templates/archive-coverage.html +++ b/swh/web/archive_coverage/templates/archive-coverage.html @@ -1,170 +1,170 @@ {% comment %} Copyright (C) 2015-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load js_reverse %} {% load static %} {% load render_bundle from webpack_loader %} {% include "includes/favicon.html" %} Software Heritage archive coverage {% render_bundle 'vendors' %} {% render_bundle 'webapp' %} - +

A significant amount of source code has already been ingested in the Software Heritage archive. It notably includes the following software origins.

{% for origins_type, origins_data in origins.items %}
{{ origins_type }}

{{ origins_data.info | safe }}

{% for origins in origins_data.origins %}
{% with 'img/logos/'|add:origins.type.lower|add:'.png' as png_logo %} {% endwith %}
{% if "instances" in origins %} {% for instance, visit_types in origins.instances.items %} {% for visit_type, data in visit_types.items %} {% if data.count %} {% endif %} {% endfor %} {% endfor %} {% else %} {% for visit_type, search_url in origins.search_urls.items %} {% endfor %} {% endif %}
instance type count search
{{ instance }} {{ visit_type }} {{ data.count }} {% if data.search_url %} {% endif %}
instance type search
{{ origins.type }} {{ visit_type }} {% if search_url %} {% endif %}
{% endfor %}
{% endfor %}
{% if "swh.web.jslicenses" in SWH_DJANGO_APPS %} JavaScript license information {% endif %} diff --git a/swh/web/browse/templates/browse-iframe.html b/swh/web/browse/templates/browse-iframe.html index 893c347e..d8341a93 100644 --- a/swh/web/browse/templates/browse-iframe.html +++ b/swh/web/browse/templates/browse-iframe.html @@ -1,184 +1,184 @@ {% comment %} Copyright (C) 2021-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load static %} {% load render_bundle from webpack_loader %} {% load swh_templatetags %} Software Heritage archived object - + {% render_bundle 'vendors' %} {% render_bundle 'webapp' %} {% render_bundle 'browse' %}
{% include "includes/show-swhids.html" %}
Software Heritage
Navigating in
{% if swhid != focus_swhid %}
Reset view
{% endif %} View in the archive
{% if error_code != 200 %} {% include "includes/http-error.html" %} {% elif object_type == "cnt" %} {% include "includes/content-display.html" %} {% elif object_type == "dir" %} {% include "includes/directory-display.html" %} {% endif %}
{% if "swh.web.jslicenses" in SWH_DJANGO_APPS %} JavaScript license information {% endif %} {% if object_type == "cnt" %} {% endif %} diff --git a/swh/web/browse/urls.py b/swh/web/browse/urls.py index 7665ab8b..782a4909 100644 --- a/swh/web/browse/urls.py +++ b/swh/web/browse/urls.py @@ -1,65 +1,76 @@ # Copyright (C) 2017-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect, render from django.urls import re_path as url from swh.web.browse.browseurls import BrowseUrls from swh.web.browse.identifiers import swhid_browse import swh.web.browse.views.content # noqa import swh.web.browse.views.directory # noqa import swh.web.browse.views.iframe # noqa import swh.web.browse.views.origin # noqa import swh.web.browse.views.release # noqa import swh.web.browse.views.revision # noqa import swh.web.browse.views.snapshot # noqa from swh.web.utils import origin_visit_types, reverse def _browse_help_view(request: HttpRequest) -> HttpResponse: return render( request, "browse-help.html", {"heading": "How to browse the archive ?"} ) def _browse_search_view(request: HttpRequest) -> HttpResponse: return render( request, "browse-search.html", { "heading": "Search software origins to browse", "visit_types": origin_visit_types(), }, ) def _browse_origin_save_view(request: HttpRequest) -> HttpResponse: return redirect(reverse("origin-save")) def _browse_swhid_iframe_legacy(request: HttpRequest, swhid: str) -> HttpResponse: return redirect(reverse("browse-swhid-iframe", url_args={"swhid": swhid})) urlpatterns = [ url(r"^browse/$", _browse_search_view), url(r"^browse/help/$", _browse_help_view, name="browse-help"), url(r"^browse/search/$", _browse_search_view, name="browse-search"), # for backward compatibility url(r"^browse/origin/save/$", _browse_origin_save_view, name="browse-origin-save"), url( r"^browse/(?Pswh:[0-9]+:[a-z]+:[0-9a-f]+.*)/$", swhid_browse, name="browse-swhid-legacy", ), url( r"^embed/(?Pswh:[0-9]+:[a-z]+:[0-9a-f]+.*)/$", _browse_swhid_iframe_legacy, name="browse-swhid-iframe-legacy", ), + # keep legacy SWHID resolving URL with trailing slash for backward compatibility + url( + r"^(?P(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)/$", + swhid_browse, + name="browse-swhid-legacy", + ), + url( + r"^(?P(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)$", + swhid_browse, + name="browse-swhid", + ), ] urlpatterns += BrowseUrls.get_url_patterns() diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py index 04abc13c..5ae708a2 100644 --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -1,349 +1,351 @@ # Copyright (C) 2017-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information """ Django common settings for swh-web. """ from importlib.util import find_spec import os import sys from typing import Any, Dict from django.utils import encoding from swh.web.config import get_config # Fix django-js-reverse 0.9.1 compatibility with django 4.x # TODO: Remove that hack once a new django-js-reverse release # is available on PyPI if not hasattr(encoding, "force_text"): setattr(encoding, "force_text", encoding.force_str) swh_web_config = get_config() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = swh_web_config["secret_key"] # SECURITY WARNING: don't run with debug turned on in production! DEBUG = swh_web_config["debug"] DEBUG_PROPAGATE_EXCEPTIONS = swh_web_config["debug"] ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + swh_web_config["allowed_hosts"] # Application definition SWH_BASE_DJANGO_APPS = [ + "swh.web.webapp", "swh.web.auth", "swh.web.browse", "swh.web.utils", + "swh.web.tests", "swh.web.api", ] SWH_EXTRA_DJANGO_APPS = [ app for app in swh_web_config["swh_extra_django_apps"] if app not in SWH_BASE_DJANGO_APPS ] # swh.web.api must be the last loaded application due to the way # its URLS are registered SWH_DJANGO_APPS = SWH_EXTRA_DJANGO_APPS + SWH_BASE_DJANGO_APPS INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", "webpack_loader", "django_js_reverse", "corsheaders", ] + SWH_DJANGO_APPS MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "swh.auth.django.middlewares.OIDCSessionExpiredMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "swh.web.utils.middlewares.ThrottlingHeadersMiddleware", "swh.web.utils.middlewares.ExceptionMiddleware", ] # Compress all assets (static ones and dynamically generated html) # served by django in a local development environment context. # In a production environment, assets compression will be directly # handled by web servers like apache or nginx. if swh_web_config["serve_assets"]: MIDDLEWARE.insert(0, "django.middleware.gzip.GZipMiddleware") ROOT_URLCONF = "swh.web.urls" SWH_APP_TEMPLATES = [os.path.join(PROJECT_DIR, "../templates")] # Add templates directory from each SWH Django application for app in SWH_DJANGO_APPS: try: app_spec = find_spec(app) assert app_spec is not None, f"Django application {app} not found !" assert app_spec.origin is not None SWH_APP_TEMPLATES.append( os.path.join(os.path.dirname(app_spec.origin), "templates") ) except ModuleNotFoundError: assert False, f"Django application {app} not found !" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": SWH_APP_TEMPLATES, "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "swh.web.utils.context_processor", ], "libraries": { "swh_templatetags": "swh.web.utils.swh_templatetags", }, }, }, ] DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": swh_web_config.get("development_db", ""), } } # Password validation # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa }, { "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] # Internationalization # https://docs.djangoproject.com/en/1.11/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATIC_URL = "/static/" # static folder location when swh-web has been installed with pip STATIC_DIR = os.path.join(sys.prefix, "share/swh/web/static") if not os.path.exists(STATIC_DIR): # static folder location when developping swh-web STATIC_DIR = os.path.join(PROJECT_DIR, "../../../static") STATICFILES_DIRS = [STATIC_DIR] INTERNAL_IPS = ["127.0.0.1"] throttle_rates = {} http_requests = ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] throttling = swh_web_config["throttling"] for limiter_scope, limiter_conf in throttling["scopes"].items(): if "default" in limiter_conf["limiter_rate"]: throttle_rates[limiter_scope] = limiter_conf["limiter_rate"]["default"] # for backward compatibility else: throttle_rates[limiter_scope] = limiter_conf["limiter_rate"] # register sub scopes specific for HTTP request types for http_request in http_requests: if http_request in limiter_conf["limiter_rate"]: throttle_rates[limiter_scope + "_" + http_request.lower()] = limiter_conf[ "limiter_rate" ][http_request] REST_FRAMEWORK: Dict[str, Any] = { "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", "swh.web.api.renderers.YAMLRenderer", "rest_framework.renderers.TemplateHTMLRenderer", ), "DEFAULT_THROTTLE_CLASSES": ( "swh.web.api.throttling.SwhWebRateThrottle", "swh.web.api.throttling.SwhWebUserRateThrottle", ), "DEFAULT_THROTTLE_RATES": throttle_rates, "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", "swh.auth.django.backends.OIDCBearerTokenAuthentication", ], "EXCEPTION_HANDLER": "swh.web.api.apiresponse.error_response_handler", } LOGGING = { "version": 1, "disable_existing_loggers": False, "filters": { "require_debug_false": { "()": "django.utils.log.RequireDebugFalse", }, "require_debug_true": { "()": "django.utils.log.RequireDebugTrue", }, }, "formatters": { "request": { "format": "[%(asctime)s] [%(levelname)s] %(request)s %(status_code)s", "datefmt": "%d/%b/%Y %H:%M:%S", }, "simple": { "format": "[%(asctime)s] [%(levelname)s] %(message)s", "datefmt": "%d/%b/%Y %H:%M:%S", }, "verbose": { "format": ( "[%(asctime)s] [%(levelname)s] %(name)s.%(funcName)s:%(lineno)s " "- %(message)s" ), "datefmt": "%d/%b/%Y %H:%M:%S", }, }, "handlers": { "console": { "level": "DEBUG", "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "simple", }, "file": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "simple", }, "file_request": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "request", }, "console_verbose": { "level": "DEBUG", "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "verbose", }, "file_verbose": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "verbose", }, "null": { "class": "logging.NullHandler", }, }, "loggers": { "": { "handlers": ["console_verbose", "file_verbose"], "level": "DEBUG" if DEBUG else "WARNING", }, "django": { "handlers": ["console"], "level": "DEBUG" if DEBUG else "WARNING", "propagate": False, }, "django.request": { "handlers": ["file_request"], "level": "DEBUG" if DEBUG else "WARNING", "propagate": False, }, "django.db.backends": {"handlers": ["null"], "propagate": False}, "django.utils.autoreload": { "level": "INFO", }, "swh.core.statsd": { "level": "INFO", }, "urllib3": { "level": "INFO", }, }, } WEBPACK_LOADER = { "DEFAULT": { "CACHE": False, "BUNDLE_DIR_NAME": "./", "STATS_FILE": os.path.join(STATIC_DIR, "webpack-stats.json"), "POLL_INTERVAL": 0.1, "TIMEOUT": None, "IGNORE": [".+\\.hot-update.js", ".+\\.map"], } } LOGIN_URL = "/login/" LOGIN_REDIRECT_URL = "swh-web-homepage" SESSION_ENGINE = "django.contrib.sessions.backends.cache" CACHES = { "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, } JS_REVERSE_JS_MINIFY = False CORS_ORIGIN_ALLOW_ALL = True CORS_URLS_REGEX = r"^/(badge|api)/.*$" AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", "swh.auth.django.backends.OIDCAuthorizationCodePKCEBackend", ] OIDC_SWH_WEB_CLIENT_ID = "swh-web" SWH_AUTH_SERVER_URL = swh_web_config["keycloak"]["server_url"] SWH_AUTH_REALM_NAME = swh_web_config["keycloak"]["realm_name"] SWH_AUTH_CLIENT_ID = OIDC_SWH_WEB_CLIENT_ID SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW = "logout" DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/swh/web/tests/auth/test_views.py b/swh/web/tests/auth/test_views.py index c3580443..5a02662e 100644 --- a/swh/web/tests/auth/test_views.py +++ b/swh/web/tests/auth/test_views.py @@ -1,313 +1,313 @@ # Copyright (C) 2020-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import json from urllib.parse import urljoin, urlparse import uuid import pytest from django.conf import settings from django.http import QueryDict from swh.auth.keycloak import KeycloakError from swh.web.auth.models import OIDCUserOfflineTokens from swh.web.auth.utils import decrypt_data from swh.web.config import get_config from swh.web.tests.django_asserts import assert_contains from swh.web.tests.helpers import ( check_html_get_response, check_http_get_response, check_http_post_response, ) -from swh.web.urls import _default_view as homepage_view from swh.web.utils import reverse +from swh.web.webapp.urls import default_view as homepage_view def _check_oidc_login_code_flow_data( request, response, keycloak_oidc, redirect_uri, scope="openid" ): parsed_url = urlparse(response["location"]) authorization_url = keycloak_oidc.well_known()["authorization_endpoint"] query_dict = QueryDict(parsed_url.query) # check redirect url is valid assert urljoin(response["location"], parsed_url.path) == authorization_url assert "client_id" in query_dict assert query_dict["client_id"] == settings.OIDC_SWH_WEB_CLIENT_ID assert "response_type" in query_dict assert query_dict["response_type"] == "code" assert "redirect_uri" in query_dict assert query_dict["redirect_uri"] == redirect_uri assert "code_challenge_method" in query_dict assert query_dict["code_challenge_method"] == "S256" assert "scope" in query_dict assert query_dict["scope"] == scope assert "state" in query_dict assert "code_challenge" in query_dict # check a login_data has been registered in user session assert "login_data" in request.session login_data = request.session["login_data"] assert "code_verifier" in login_data assert "state" in login_data assert "redirect_uri" in login_data assert login_data["redirect_uri"] == query_dict["redirect_uri"] return login_data def test_view_rendering_when_user_not_set_in_request(request_factory): request = request_factory.get("/") # Django RequestFactory do not set any user by default assert not hasattr(request, "user") response = homepage_view(request) assert response.status_code == 200 def test_oidc_generate_bearer_token_anonymous_user(client): """ Anonymous user should be refused access with forbidden response. """ url = reverse("oidc-generate-bearer-token") check_http_get_response(client, url, status_code=403) def _generate_and_test_bearer_token(client, kc_oidc_mock): # user authenticates client.login( code="code", code_verifier="code-verifier", redirect_uri="redirect-uri" ) # user initiates bearer token generation flow url = reverse("oidc-generate-bearer-token") response = check_http_get_response(client, url, status_code=302) request = response.wsgi_request redirect_uri = reverse("oidc-generate-bearer-token-complete", request=request) # check login data and redirection to Keycloak is valid login_data = _check_oidc_login_code_flow_data( request, response, kc_oidc_mock, redirect_uri=redirect_uri, scope="openid offline_access", ) # once a user has identified himself in Keycloak, he is # redirected to the 'oidc-generate-bearer-token-complete' view # to get and save bearer token # generate authorization code / session state in the same # manner as Keycloak code = f"{str(uuid.uuid4())}.{str(uuid.uuid4())}.{str(uuid.uuid4())}" session_state = str(uuid.uuid4()) token_complete_url = reverse( "oidc-generate-bearer-token-complete", query_params={ "code": code, "state": login_data["state"], "session_state": session_state, }, ) nb_tokens = len(OIDCUserOfflineTokens.objects.all()) response = check_http_get_response(client, token_complete_url, status_code=302) request = response.wsgi_request # check token has been generated and saved encrypted to database assert len(OIDCUserOfflineTokens.objects.all()) == nb_tokens + 1 encrypted_token = OIDCUserOfflineTokens.objects.last().offline_token.tobytes() secret = get_config()["secret_key"].encode() salt = request.user.sub.encode() decrypted_token = decrypt_data(encrypted_token, secret, salt) oidc_profile = kc_oidc_mock.authorization_code(code=code, redirect_uri=redirect_uri) assert decrypted_token.decode("ascii") == oidc_profile["refresh_token"] # should redirect to tokens management Web UI assert response["location"] == reverse("oidc-profile") + "#tokens" return decrypted_token @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_oidc_generate_bearer_token_authenticated_user_success(client, keycloak_oidc): """ Authenticated user should be able to generate a bearer token using OIDC Authorization Code Flow. """ _generate_and_test_bearer_token(client, keycloak_oidc) def test_oidc_list_bearer_tokens_anonymous_user(client): """ Anonymous user should be refused access with forbidden response. """ url = reverse( "oidc-list-bearer-tokens", query_params={"draw": 1, "start": 0, "length": 10} ) check_http_get_response(client, url, status_code=403) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_oidc_list_bearer_tokens(client, keycloak_oidc): """ User with correct credentials should be allowed to list his tokens. """ nb_tokens = 3 for _ in range(nb_tokens): _generate_and_test_bearer_token(client, keycloak_oidc) url = reverse( "oidc-list-bearer-tokens", query_params={"draw": 1, "start": 0, "length": 10} ) response = check_http_get_response(client, url, status_code=200) tokens_data = list(reversed(json.loads(response.content.decode("utf-8"))["data"])) for oidc_token in OIDCUserOfflineTokens.objects.all(): assert ( oidc_token.creation_date.isoformat() == tokens_data[oidc_token.id - 1]["creation_date"] ) def test_oidc_get_bearer_token_anonymous_user(client): """ Anonymous user should be refused access with forbidden response. """ url = reverse("oidc-get-bearer-token") check_http_post_response(client, url, status_code=403) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_oidc_get_bearer_token(client, keycloak_oidc): """ User with correct credentials should be allowed to display a token. """ nb_tokens = 3 for i in range(nb_tokens): token = _generate_and_test_bearer_token(client, keycloak_oidc) url = reverse("oidc-get-bearer-token") response = check_http_post_response( client, url, status_code=200, data={"token_id": i + 1}, content_type="text/plain", ) assert response.content == token @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_oidc_get_bearer_token_expired_token(client, keycloak_oidc): """ User with correct credentials should be allowed to display a token. """ _generate_and_test_bearer_token(client, keycloak_oidc) for kc_err_msg in ("Offline session not active", "Offline user session not found"): kc_error_dict = { "error": "invalid_grant", "error_description": kc_err_msg, } keycloak_oidc.refresh_token.side_effect = KeycloakError( error_message=json.dumps(kc_error_dict).encode(), response_code=400 ) url = reverse("oidc-get-bearer-token") response = check_http_post_response( client, url, status_code=400, data={"token_id": 1}, content_type="text/plain", ) assert ( response.content == b"Bearer token has expired, please generate a new one." ) def test_oidc_revoke_bearer_tokens_anonymous_user(client): """ Anonymous user should be refused access with forbidden response. """ url = reverse("oidc-revoke-bearer-tokens") check_http_post_response(client, url, status_code=403) @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_oidc_revoke_bearer_tokens(client, keycloak_oidc): """ User with correct credentials should be allowed to revoke tokens. """ nb_tokens = 3 for _ in range(nb_tokens): _generate_and_test_bearer_token(client, keycloak_oidc) url = reverse("oidc-revoke-bearer-tokens") check_http_post_response( client, url, status_code=200, data={"token_ids": [1]}, ) assert len(OIDCUserOfflineTokens.objects.all()) == 2 check_http_post_response( client, url, status_code=200, data={"token_ids": [2, 3]}, ) assert len(OIDCUserOfflineTokens.objects.all()) == 0 def test_oidc_profile_view_anonymous_user(client): """ Non authenticated users should be redirected to login page when requesting profile view. """ url = reverse("oidc-profile") login_url = reverse("oidc-login", query_params={"next_path": url}) resp = check_http_get_response(client, url, status_code=302) assert resp["location"] == login_url @pytest.mark.django_db(transaction=True, reset_sequences=True) def test_oidc_profile_view(client, keycloak_oidc): """ Authenticated users should be able to request the profile page and link to Keycloak account UI should be present. """ url = reverse("oidc-profile") kc_config = get_config()["keycloak"] client_permissions = ["perm1", "perm2"] keycloak_oidc.client_permissions = client_permissions client.login(code="", code_verifier="", redirect_uri="") resp = check_html_get_response( client, url, status_code=200, template_used="profile.html" ) user = resp.wsgi_request.user kc_account_url = ( f"{kc_config['server_url']}realms/{kc_config['realm_name']}/account/" ) assert_contains(resp, kc_account_url) assert_contains(resp, user.username) assert_contains(resp, user.first_name) assert_contains(resp, user.last_name) assert_contains(resp, user.email) for perm in client_permissions: assert_contains(resp, perm) diff --git a/swh/web/tests/test_urls.py b/swh/web/tests/test_urls.py index be771132..ff337094 100644 --- a/swh/web/tests/test_urls.py +++ b/swh/web/tests/test_urls.py @@ -1,35 +1,27 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import pytest from django.urls import get_resolver -from swh.web.tests.helpers import check_http_get_response -from swh.web.utils import reverse - def test_swh_web_urls_have_trailing_slash(): urls = set( value[1] for key, value in get_resolver().reverse_dict.items() if key != "browse-swhid" # (see T3234) ) for url in urls: if url != "$": assert url.endswith("/$") def test_urls_registration_error_for_not_found_django_app(django_settings): app_name = "swh.web.foobar" with pytest.raises( AssertionError, match=f"Django application {app_name} not found !" ): django_settings.SWH_DJANGO_APPS = django_settings.SWH_DJANGO_APPS + [app_name] - - -def test_stat_counters_view(client): - url = reverse("stat-counters") - check_http_get_response(client, url, status_code=200) diff --git a/swh/web/tests/webapp/__init__.py b/swh/web/tests/webapp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/swh/web/tests/test_templates.py b/swh/web/tests/webapp/test_templates.py similarity index 100% rename from swh/web/tests/test_templates.py rename to swh/web/tests/webapp/test_templates.py diff --git a/swh/web/tests/webapp/test_views.py b/swh/web/tests/webapp/test_views.py new file mode 100644 index 00000000..02fd0867 --- /dev/null +++ b/swh/web/tests/webapp/test_views.py @@ -0,0 +1,31 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.templatetags.static import static + +from swh.web.tests.helpers import check_html_get_response, check_http_get_response +from swh.web.utils import reverse +from swh.web.webapp.urls import SWH_FAVICON + + +def test_homepage_view(client): + url = reverse("swh-web-homepage") + check_html_get_response(client, url, status_code=200, template_used="homepage.html") + + +def test_stat_counters_view(client): + url = reverse("stat-counters") + check_http_get_response(client, url, status_code=200) + + +def test_js_reverse_view(client): + url = reverse("js-reverse") + check_http_get_response(client, url, status_code=200) + + +def test_favicon_view(client): + url = reverse("favicon") + resp = check_http_get_response(client, url, status_code=301) + assert resp["location"] == static(SWH_FAVICON) diff --git a/swh/web/urls.py b/swh/web/urls.py index 0be2d0fc..8cddaf63 100644 --- a/swh/web/urls.py +++ b/swh/web/urls.py @@ -1,101 +1,48 @@ # Copyright (C) 2017-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from importlib.util import find_spec -import json from typing import List, Union -from django_js_reverse.views import urls_js -import requests - from django.conf import settings from django.conf.urls import handler400, handler403, handler404, handler500, include from django.contrib.staticfiles.views import serve -from django.http import JsonResponse -from django.shortcuts import render from django.urls import URLPattern, URLResolver from django.urls import re_path as url -from django.views.generic.base import RedirectView -from swh.web.browse.identifiers import swhid_browse from swh.web.config import get_config -from swh.web.utils import archive, origin_visit_types from swh.web.utils.exc import swh_handle400, swh_handle403, swh_handle404, swh_handle500 swh_web_config = get_config() -favicon_view = RedirectView.as_view( - url="/static/img/icons/swh-logo-32x32.png", permanent=True -) - - -def _default_view(request): - return render(request, "homepage.html", {"visit_types": origin_visit_types()}) - - -def _stat_counters(request): - stat_counters = archive.stat_counters() - url = get_config()["history_counters_url"] - stat_counters_history = {} - - if url: - response = requests.get(url, timeout=5) - stat_counters_history = json.loads(response.text) - - counters = { - "stat_counters": stat_counters, - "stat_counters_history": stat_counters_history, - } - return JsonResponse(counters) - - urlpatterns: List[Union[URLPattern, URLResolver]] = [] # Register URLs for each SWH Django application for app in settings.SWH_DJANGO_APPS: app_urls = app + ".urls" try: app_urls_spec = find_spec(app_urls) if app_urls_spec is not None: urlpatterns.append(url(r"^", include(app_urls))) except ModuleNotFoundError: assert False, f"Django application {app} not found !" -urlpatterns += [ - url(r"^favicon\.ico/$", favicon_view), - url(r"^$", _default_view, name="swh-web-homepage"), - url(r"^jsreverse/$", urls_js, name="js_reverse"), - # keep legacy SWHID resolving URL with trailing slash for backward compatibility - url( - r"^(?P(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)/$", - swhid_browse, - name="browse-swhid-legacy", - ), - url( - r"^(?P(swh|SWH):[0-9]+:[A-Za-z]+:[0-9A-Fa-f]+.*)$", - swhid_browse, - name="browse-swhid", - ), - url(r"^stat_counters/$", _stat_counters, name="stat-counters"), - url(r"^", include("swh.web.tests.urls")), -] - # allow to serve assets through django staticfiles # even if settings.DEBUG is False def insecure_serve(request, path, **kwargs): return serve(request, path, insecure=True, **kwargs) # enable to serve compressed assets through django development server if swh_web_config["serve_assets"]: static_pattern = r"^%s(?P.*)/$" % settings.STATIC_URL[1:] urlpatterns.append(url(static_pattern, insecure_serve)) handler400 = swh_handle400 # noqa handler403 = swh_handle403 # noqa handler404 = swh_handle404 # noqa handler500 = swh_handle500 # noqa diff --git a/swh/web/webapp/__init__.py b/swh/web/webapp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/swh/web/templates/error.html b/swh/web/webapp/templates/error.html similarity index 100% rename from swh/web/templates/error.html rename to swh/web/webapp/templates/error.html diff --git a/swh/web/templates/homepage.html b/swh/web/webapp/templates/homepage.html similarity index 100% rename from swh/web/templates/homepage.html rename to swh/web/webapp/templates/homepage.html diff --git a/swh/web/templates/includes/favicon.html b/swh/web/webapp/templates/includes/favicon.html similarity index 100% rename from swh/web/templates/includes/favicon.html rename to swh/web/webapp/templates/includes/favicon.html diff --git a/swh/web/templates/includes/global-modals.html b/swh/web/webapp/templates/includes/global-modals.html similarity index 88% rename from swh/web/templates/includes/global-modals.html rename to swh/web/webapp/templates/includes/global-modals.html index d2929706..7040f7b1 100644 --- a/swh/web/templates/includes/global-modals.html +++ b/swh/web/webapp/templates/includes/global-modals.html @@ -1,53 +1,60 @@ +{% comment %} +Copyright (C) 2018-2022 The Software Heritage developers +See the AUTHORS file at the top-level directory of this distribution +License: GNU Affero General Public License version 3, or any later version +See top-level LICENSE file for more information +{% endcomment %} + diff --git a/swh/web/templates/includes/http-error.html b/swh/web/webapp/templates/includes/http-error.html similarity index 100% rename from swh/web/templates/includes/http-error.html rename to swh/web/webapp/templates/includes/http-error.html diff --git a/swh/web/templates/layout.html b/swh/web/webapp/templates/layout.html similarity index 99% rename from swh/web/templates/layout.html rename to swh/web/webapp/templates/layout.html index 5795a0b2..a54c74d9 100644 --- a/swh/web/templates/layout.html +++ b/swh/web/webapp/templates/layout.html @@ -1,329 +1,329 @@ {% comment %} Copyright (C) 2015-2022 The Software Heritage developers See the AUTHORS file at the top-level directory of this distribution License: GNU Affero General Public License version 3, or any later version See top-level LICENSE file for more information {% endcomment %} {% load js_reverse %} {% load static %} {% load render_bundle from webpack_loader %} {% load swh_templatetags %} {% block title %}{% endblock %} {% render_bundle 'vendors' %} {% render_bundle 'webapp' %} {% render_bundle 'guided_tour' %} - + {{ request.user.is_authenticated|json_script:"swh_user_logged_in" }} {% include "includes/favicon.html" %} {% block header %}{% endblock %} {% if swh_web_prod %} {% endif %}
{% if "swh.web.banners" in SWH_DJANGO_APPS %}
{% include "hiring-banner.html" %}
{% endif %}
{% if swh_web_staging %}
Staging
v{{ swh_web_version }}
{% elif swh_web_dev %}
Development
v{{ swh_web_version|split:"+"|first }}
{% endif %} {% block content %}{% endblock %}
{% include "includes/global-modals.html" %}
Software Heritage — Copyright (C) 2015–{% now "Y" %}, The Software Heritage developers. License: GNU AGPLv3+.
The source code of Software Heritage itself is available on our development forge.
The source code files archived by Software Heritage are available under their own copyright and licenses.
Terms of use: Archive access, API- Contact- {% if "swh.web.jslicenses" in SWH_DJANGO_APPS %} JavaScript license information- {% endif %} Web API
{% if "production" not in DJANGO_SETTINGS_MODULE %} swh-web v{{ swh_web_version }} {% endif %}
back to top
diff --git a/swh/web/webapp/urls.py b/swh/web/webapp/urls.py new file mode 100644 index 00000000..020572b8 --- /dev/null +++ b/swh/web/webapp/urls.py @@ -0,0 +1,52 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import json + +from django_js_reverse.views import urls_js +import requests + +from django.http import JsonResponse +from django.shortcuts import render +from django.templatetags.static import static +from django.urls import re_path as url +from django.views.generic.base import RedirectView + +from swh.web.config import get_config +from swh.web.utils import archive, origin_visit_types + +swh_web_config = get_config() + +SWH_FAVICON = "img/icons/swh-logo-32x32.png" + +favicon_view = RedirectView.as_view(url=static(SWH_FAVICON), permanent=True) + + +def default_view(request): + return render(request, "homepage.html", {"visit_types": origin_visit_types()}) + + +def stat_counters(request): + stat_counters = archive.stat_counters() + url = get_config()["history_counters_url"] + stat_counters_history = {} + + if url: + response = requests.get(url, timeout=5) + stat_counters_history = json.loads(response.text) + + counters = { + "stat_counters": stat_counters, + "stat_counters_history": stat_counters_history, + } + return JsonResponse(counters) + + +urlpatterns = [ + url(r"^favicon\.ico/$", favicon_view, name="favicon"), + url(r"^$", default_view, name="swh-web-homepage"), + url(r"^jsreverse/$", urls_js, name="js-reverse"), + url(r"^stat_counters/$", stat_counters, name="stat-counters"), +]